Skip to content

feat: feat(cluster-protocol): publish the TypeScript cluster client with full v1 parity - #799

Closed
tomdps wants to merge 1 commit into
devfrom
zeroshot/crimson-keep-65
Closed

feat: feat(cluster-protocol): publish the TypeScript cluster client with full v1 parity#799
tomdps wants to merge 1 commit into
devfrom
zeroshot/crimson-keep-65

Conversation

@tomdps

@tomdps tomdps commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Closes #748

@tomdps
tomdps enabled auto-merge July 25, 2026 04:58
@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown

Greptile Summary

Adds a publishable TypeScript v1 Cluster Protocol client.

  • Generates and packages the protocol wire types for CommonJS and ESM consumers.
  • Implements unary JSON-RPC calls, multiplexed WebSocket transport, and watch/logs/agent-attach subscriptions.
  • Adds reconnect, cancellation, overflow handling, package smoke tests, and CI checks.

Confidence Score: 3/5

The PR should not merge until watch reconnection uses the fresh connection and request IDs cannot collide between clients sharing a transport.

A real WebSocket reconnect sends the replacement watch over the disconnected transport, while independently constructed clients can also reject valid concurrent calls because their per-client counters produce duplicate IDs in one transport-wide pending map.

Files Needing Attention: src/cluster/watch-subscription.ts, src/cluster/cluster-client.ts, src/cluster/multiplex.ts

Important Files Changed

Filename Overview
src/cluster/watch-subscription.ts Implements deduplicated watch streams, but reconnect mixes the supplied fresh client with the original closed subscription transport.
src/cluster/cluster-client.ts Implements typed unary calls and cancellation, but allocates IDs per client despite transports being shareable.
src/cluster/multiplex.ts Adds request/subscription demultiplexing and bounded queues; its transport-wide pending map exposes per-client request-ID collisions.
src/cluster/websocket-transport.ts Adds the Node/browser WebSocket binding and connects it to the multiplexed transport.
package.json Publishes the cluster subpath and adds generation, build, test, and lifecycle scripts.
scripts/generate-cluster-protocol-ts.js Generates TypeScript protocol projections and supports deterministic drift checking.

Sequence Diagram

sequenceDiagram
    participant App
    participant OldWatch as Original watch transport
    participant FreshClient as Supplied ClusterClient
    participant FreshSocket as Fresh transport
    App->>OldWatch: stream.reconnect(FreshClient)
    OldWatch->>FreshClient: get(atCursor)
    FreshClient->>FreshSocket: JSON-RPC get
    FreshSocket-->>FreshClient: coherent snapshot
    FreshClient-->>OldWatch: snapshot
    OldWatch->>OldWatch: establishWatch(this.transport)
    OldWatch--xOldWatch: send through closed original socket
    OldWatch-->>App: reconnect rejects
Loading

Reviews (1): Last reviewed commit: "feat: implement #748 - feat(cluster-prot..." | Re-trigger Greptile

runId: this.runId,
fromCursor: snapshot.atCursor ?? this.lastDelivered,
};
return establishWatch(this.transport, params, this.seen);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Reconnect reuses closed transport

When the original WebSocket disconnects and the caller supplies a client using a freshly dialed connection, get() uses the fresh transport but establishWatch(this.transport, ...) sends the replacement watch through the original closed socket, causing reconnect() to reject instead of restoring the stream.

Context Used: AGENTS.md (source)


export class ClusterClient {
private readonly transport: JsonRpcTransport;
private nextId = 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Request IDs collide across clients

When two ClusterClient instances share one transport and have overlapping requests at the same sequence number, both allocate the same ID from their per-client counters. The transport-wide pending map rejects the second valid request as already pending instead of sending it.

@tomdps
tomdps disabled auto-merge July 25, 2026 05:06
@tomdps

tomdps commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI and external review found three release-blocking defects: the package architecture test fails because the cluster guard comment names the provider-helper source; reconnect opens the replacement watch on the closed original transport; and per-client request counters collide when clients share one multiplexed transport. Closing this attempt so a fresh Zeroshot run can repair the contract from current dev.

@tomdps tomdps closed this Jul 25, 2026
jcarter pushed a commit to jcarter/zeroshot that referenced this pull request Jul 29, 2026
…ngine#843)

Closes the-open-engine#748

## Outcome

Publishes `@the-open-engine/zeroshot/cluster` as a Node 18+
CJS/ESM/TypeScript WebSocket client for the full v1 cluster protocol.

## Ownership decisions

- The connection owns collision-free request IDs, pending entries, exact
failed-send cleanup, bounded frame decoding, response routing, and
OPEN/CLOSING/CLOSED teardown.
- Every subscription uses the shared 1024-entry bounded multi-waiter
queue; overflow unregisters locally, cancels remotely best-effort,
clears retained frames, and returns the protocol slow-consumer close.
- Durable watch owns one shared reconnect attempt, reconstructs state
via coherent `get` then `watch(fromCursor)`, and suppresses the last
ordered physical `(runId,cursor)` redelivery with constant memory across
reconnects.
- Abort/iterator return detach their exact listeners and cancel exactly
once; logs and attach expose terminal closure without a recoverable
cursor.
- Wire types, method/result maps, validators, constants, and the
embedded schema are generated deterministically from checked-in
Rust/OpenRPC artifacts. There are no hand-maintained DTOs.
- The package exports preserve the root/deep-import surface while adding
the built `cluster` subpath, optional installed `ws` runtime, and
injectable browser-compatible factory.

The issue's two ownership-design comments were followed directly. The
claimed checked-in `docs/v2/748-typescript-client-design.md` was absent
from fresh `origin/main`, so no replacement design document was
fabricated.

## Regression coverage

Deterministic tests cover every prior the-open-engine#799-the-open-engine#802 defect class: shared-ID
collisions, exact pending cleanup after failed sends, 1024-entry
overflow for all stream types, failed/competing reconnect ownership,
close at every reconnect boundary, multi-waiter FIFO, close cleanup
after cancel failure, post-handshake abort, malformed peer frames and
bounded diagnostics, generated response validation, and packed
CJS/ESM/TypeScript/default-`ws` consumers.

External review added and closed two security P1s before merge:
durable-watch dedup state is scalar/constant-memory over thousands of
unique events, and inbound binary/text byte limits are enforced before
decode/encoding-copy allocation. Both review threads include fix and
independent probe evidence.

Independent non-authoring verifiers required four rounds. Their final
runs reproduced the historical races plus malformed-frame,
error-envelope, cancellation, installed-runtime, 5,000-event dedup,
UTF-8 boundary, and decoder-allocation probes. No P0/P1/security finding
remains.

## Verification

- `npm run protocol:check`
- `npm run typecheck`
- `npm run lint` (0 errors; existing warnings remain)
- `npm run test:cluster-client` — 38/38
- `npm run test:cluster-package` — 4/4
- `npm test` — 1953 passing, 18 pending
- `npm pack --dry-run`
- `npm run rust:check`
- `npm run test:unit` — 1953 passing, 18 pending
- `npm run check:agent-cli-provider:ci` — 147/147
- `npm run dupcheck`
- `opcore check --changed`

## Non-goals preserved

No Rust dispatcher/server/client behavior, in-process TypeScript
transport, hosted authentication, provisioning, or native-daemon
behavior changed.

## Discovered protocol work

`WatchResult` uniquely lacks Rust `#[serde(deny_unknown_fields)]`, so
its generated schema permits unknown properties while `LogsResult` and
`AgentAttachResult` are closed. This PR intentionally follows generated
authority rather than hand-tightening the TypeScript validator.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant